1use std::path::Path;
43
44use rusqlite::{params, Connection};
45use tracing::{info, warn};
46
47use super::error::StoreError;
48
49pub const CONSOLIDATE_MARKER: &str = "migration_agents_consolidate_v1.flag";
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
55pub struct ConsolidateStats {
56 pub templates_inserted: usize,
57 pub user_defs_inserted: usize,
58 pub instances_as_clone_inserted: usize,
59 pub instances_folded_into_def: usize,
60 pub instances_skipped_continuation: usize,
61 pub instances_skipped_no_definition: usize,
62 pub instances_collision_warned: usize,
63 pub already_done: bool,
64}
65
66pub fn run_consolidate_migration(
76 conn: &mut Connection,
77 data_dir: Option<&Path>,
78) -> Result<ConsolidateStats, StoreError> {
79 if let Some(dir) = data_dir {
81 let marker = dir.join(CONSOLIDATE_MARKER);
82 if marker.exists() {
83 return Ok(ConsolidateStats {
84 already_done: true,
85 ..Default::default()
86 });
87 }
88 }
89
90 let tx = conn.transaction()?;
95
96 let mut stats = ConsolidateStats::default();
97
98 {
100 let mut def_stmt = tx.prepare(
101 "SELECT id, name, icon, provider, description,
102 working_directory, shell, provider_flags, auto_start,
103 restart_on_crash, idle_timeout_minutes, created_at,
104 agent_type, environment, agent_bus_id, is_seeded,
105 accounts, parent_id, branch_label, updated_at, slug
106 FROM db_agent_definitions",
107 )?;
108 let rows = def_stmt.query_map([], |row| {
109 Ok(DefRow {
110 id: row.get(0)?,
111 name: row.get(1)?,
112 icon: row.get(2)?,
113 provider: row.get(3)?,
114 description: row.get(4)?,
115 working_directory: row.get(5)?,
116 shell: row.get(6)?,
117 provider_flags: row.get(7)?,
118 auto_start: row.get(8)?,
119 restart_on_crash: row.get(9)?,
120 idle_timeout_minutes: row.get(10)?,
121 created_at: row.get(11)?,
122 agent_type: row.get(12)?,
123 environment: row.get(13)?,
124 agent_bus_id: row.get(14)?,
125 is_seeded: row.get(15)?,
126 accounts: row.get(16)?,
127 parent_id: row.get(17)?,
128 branch_label: row.get(18)?,
129 updated_at: row.get(19)?,
130 slug: row.get(20)?,
131 })
132 })?;
133 let mut defs: Vec<DefRow> = Vec::new();
134 for r in rows {
135 defs.push(r?);
136 }
137 drop(def_stmt);
138 for def in &defs {
139 let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
140 let parent_template_id = if def.is_seeded == 1 {
141 String::new()
142 } else {
143 def.parent_id.clone()
144 };
145 tx.execute(
150 "INSERT OR REPLACE INTO db_agents (
151 id, name, icon, description,
152 is_template, parent_template_id,
153 provider, provider_flags, shell, environment,
154 agent_type, agent_bus_id, accounts,
155 auto_start, restart_on_crash, idle_timeout_minutes,
156 slug, branch_label,
157 identity_id, memory_id, working_directory, github_context,
158 instance_name,
159 created_at, updated_at, is_seeded, user_hidden
160 ) VALUES (
161 ?1, ?2, ?3, ?4,
162 ?5, ?6,
163 ?7, ?8, ?9, ?10,
164 ?11, ?12, ?13,
165 ?14, ?15, ?16,
166 ?17, ?18,
167 '', '', '', '',
168 '',
169 ?19, ?20, ?21, 0
170 )",
171 params![
172 def.id,
173 def.name,
174 def.icon,
175 def.description,
176 is_template,
177 parent_template_id,
178 def.provider,
179 def.provider_flags,
180 def.shell,
181 def.environment,
182 def.agent_type,
183 def.agent_bus_id,
184 def.accounts,
185 def.auto_start,
186 def.restart_on_crash,
187 def.idle_timeout_minutes,
188 def.slug,
189 def.branch_label,
190 def.created_at,
191 def.updated_at,
192 def.is_seeded,
193 ],
194 )?;
195 if def.is_seeded == 1 {
196 stats.templates_inserted += 1;
197 } else {
198 stats.user_defs_inserted += 1;
199 }
200 }
201 }
202
203 let inst_rows: Vec<InstanceRow> = {
208 let mut stmt = tx.prepare(
209 "SELECT i.id, i.definition_id, i.parent_instance_id,
210 i.instance_name, i.identity_id, i.memory_id,
211 i.working_directory, i.github_context,
212 i.created_at, i.display_hidden,
213 d.is_seeded, d.name, d.icon, d.description,
214 d.provider, d.provider_flags, d.shell, d.environment,
215 d.agent_type, d.agent_bus_id, d.accounts,
216 d.auto_start, d.restart_on_crash, d.idle_timeout_minutes,
217 d.slug, d.branch_label
218 FROM db_agent_instances i
219 LEFT JOIN db_agent_definitions d ON d.id = i.definition_id
220 ORDER BY i.created_at DESC",
221 )?;
222 let iter = stmt.query_map([], |row| {
223 Ok(InstanceRow {
224 id: row.get(0)?,
225 definition_id: row.get(1)?,
226 parent_instance_id: row.get(2)?,
227 instance_name: row.get(3)?,
228 identity_id: row.get(4)?,
229 memory_id: row.get(5)?,
230 working_directory: row.get(6)?,
231 github_context: row.get(7)?,
232 created_at: row.get(8)?,
233 display_hidden: row.get::<_, i64>(9)? != 0,
234 def_is_seeded: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
235 def_name: row.get::<_, Option<String>>(11)?.unwrap_or_default(),
236 def_icon: row.get::<_, Option<String>>(12)?.unwrap_or_default(),
237 def_description: row.get::<_, Option<String>>(13)?.unwrap_or_default(),
238 def_provider: row.get::<_, Option<String>>(14)?.unwrap_or_default(),
239 def_provider_flags: row.get::<_, Option<String>>(15)?.unwrap_or_default(),
240 def_shell: row.get::<_, Option<String>>(16)?.unwrap_or_default(),
241 def_environment: row.get::<_, Option<String>>(17)?.unwrap_or_default(),
242 def_agent_type: row
243 .get::<_, Option<String>>(18)?
244 .unwrap_or_else(|| "standalone".to_string()),
245 def_agent_bus_id: row.get::<_, Option<String>>(19)?.unwrap_or_default(),
246 def_accounts: row.get::<_, Option<String>>(20)?.unwrap_or_default(),
247 def_auto_start: row.get::<_, Option<i64>>(21)?.unwrap_or(0),
248 def_restart_on_crash: row.get::<_, Option<i64>>(22)?.unwrap_or(0),
249 def_idle_timeout_minutes: row.get::<_, Option<i64>>(23)?.unwrap_or(0),
250 def_slug: row.get::<_, Option<String>>(24)?.unwrap_or_default(),
251 def_branch_label: row.get::<_, Option<String>>(25)?.unwrap_or_default(),
252 def_present: row.get::<_, Option<i64>>(10)?.is_some(),
253 })
254 })?;
255 let mut out = Vec::new();
256 for r in iter {
257 out.push(r?);
258 }
259 out
260 };
261
262 let mut folded: std::collections::HashSet<String> = std::collections::HashSet::new();
266
267 for inst in &inst_rows {
268 if !inst.parent_instance_id.is_empty() {
269 stats.instances_skipped_continuation += 1;
270 continue;
271 }
272 if !inst.def_present {
273 stats.instances_skipped_no_definition += 1;
277 warn!(
278 instance_id = %inst.id,
279 definition_id = %inst.definition_id,
280 "agents_consolidate: instance has no definition; skipping",
281 );
282 continue;
283 }
284 if inst.def_is_seeded == 1 {
285 let name = if inst.instance_name.is_empty() {
288 inst.def_name.clone()
289 } else {
290 inst.instance_name.clone()
291 };
292 tx.execute(
293 "INSERT OR REPLACE INTO db_agents (
294 id, name, icon, description,
295 is_template, parent_template_id,
296 provider, provider_flags, shell, environment,
297 agent_type, agent_bus_id, accounts,
298 auto_start, restart_on_crash, idle_timeout_minutes,
299 slug, branch_label,
300 identity_id, memory_id, working_directory, github_context,
301 instance_name,
302 created_at, updated_at, is_seeded, user_hidden
303 ) VALUES (
304 ?1, ?2, ?3, ?4,
305 0, ?5,
306 ?6, ?7, ?8, ?9,
307 ?10, ?11, ?12,
308 ?13, ?14, ?15,
309 ?16, ?17,
310 ?18, ?19, ?20, ?21,
311 ?22,
312 ?23, ?23, 0, ?24
313 )",
314 params![
315 inst.id,
316 name,
317 inst.def_icon,
318 inst.def_description,
319 inst.definition_id,
320 inst.def_provider,
321 inst.def_provider_flags,
322 inst.def_shell,
323 inst.def_environment,
324 inst.def_agent_type,
325 inst.def_agent_bus_id,
326 inst.def_accounts,
327 inst.def_auto_start,
328 inst.def_restart_on_crash,
329 inst.def_idle_timeout_minutes,
330 inst.def_slug,
331 inst.def_branch_label,
332 inst.identity_id,
333 inst.memory_id,
334 inst.working_directory,
335 inst.github_context,
336 inst.instance_name,
337 inst.created_at,
338 if inst.display_hidden { 1_i64 } else { 0_i64 },
339 ],
340 )?;
341 stats.instances_as_clone_inserted += 1;
342 } else {
343 if folded.contains(&inst.definition_id) {
348 stats.instances_collision_warned += 1;
349 warn!(
350 instance_id = %inst.id,
351 definition_id = %inst.definition_id,
352 "agents_consolidate: multiple instances on one user-cloned def; keeping most-recent bindings",
353 );
354 continue;
355 }
356 let name = if inst.instance_name.is_empty() {
357 inst.def_name.clone()
358 } else {
359 inst.instance_name.clone()
360 };
361 tx.execute(
378 "UPDATE db_agents SET
379 name = ?1,
380 identity_id = ?2,
381 memory_id = ?3,
382 working_directory = ?4,
383 github_context = ?5,
384 instance_name = ?6,
385 user_hidden = ?7,
386 updated_at = MAX(updated_at, ?8)
387 WHERE id = ?9 AND is_template = 0",
388 params![
389 name,
390 inst.identity_id,
391 inst.memory_id,
392 inst.working_directory,
393 inst.github_context,
394 inst.instance_name,
395 if inst.display_hidden { 1_i64 } else { 0_i64 },
396 inst.created_at,
397 inst.definition_id,
398 ],
399 )?;
400 folded.insert(inst.definition_id.clone());
401 stats.instances_folded_into_def += 1;
402 }
403 }
404
405 tx.commit()?;
406
407 if let Some(dir) = data_dir {
410 let marker = dir.join(CONSOLIDATE_MARKER);
411 if let Err(e) = std::fs::write(&marker, b"phase3a") {
412 warn!(
416 error = %e,
417 marker = %marker.display(),
418 "agents_consolidate: failed to write marker; next startup will redo backfill",
419 );
420 }
421 }
422
423 info!(
424 templates_inserted = stats.templates_inserted,
425 user_defs_inserted = stats.user_defs_inserted,
426 instances_as_clone_inserted = stats.instances_as_clone_inserted,
427 instances_folded_into_def = stats.instances_folded_into_def,
428 instances_skipped_continuation = stats.instances_skipped_continuation,
429 instances_skipped_no_definition = stats.instances_skipped_no_definition,
430 instances_collision_warned = stats.instances_collision_warned,
431 "agents_consolidate: backfill completed",
432 );
433 Ok(stats)
434}
435
436pub fn repair_def_gaps(conn: &mut Connection) -> Result<usize, StoreError> {
451 let mut stmt = conn.prepare(
455 "SELECT d.id, d.name, d.icon, d.provider, d.description,
456 d.working_directory, d.shell, d.provider_flags, d.auto_start,
457 d.restart_on_crash, d.idle_timeout_minutes, d.created_at,
458 d.agent_type, d.environment, d.agent_bus_id, d.is_seeded,
459 d.accounts, d.parent_id, d.branch_label, d.updated_at,
460 d.slug, d.user_hidden
461 FROM db_agent_definitions d
462 LEFT JOIN db_agents a ON a.id = d.id
463 WHERE a.id IS NULL",
464 )?;
465
466 #[allow(clippy::type_complexity)]
467 let missing: Vec<(
468 String, String, String, String, String, String, String, String,
469 i64, i64, i64, i64, String, String, String, i64, String, String,
470 String, i64, String, i64,
471 )> = stmt
472 .query_map([], |row| {
473 Ok((
474 row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, i64>(8)?, row.get::<_, i64>(9)?, row.get::<_, i64>(10)?, row.get::<_, i64>(11)?, row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, String>(14)?, row.get::<_, i64>(15)?, row.get::<_, String>(16)?, row.get::<_, String>(17)?, row.get::<_, String>(18)?, row.get::<_, i64>(19)?, row.get::<_, String>(20)?, row.get::<_, i64>(21)?, ))
497 })?
498 .collect::<Result<Vec<_>, _>>()?;
499 drop(stmt);
500
501 if missing.is_empty() {
502 return Ok(0);
503 }
504
505 let tx = conn.transaction()?;
506 let mut inserted = 0usize;
507 for (
508 id, name, icon, provider, description, working_directory, shell,
509 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
510 created_at, agent_type, environment, agent_bus_id, is_seeded,
511 accounts, parent_id, branch_label, updated_at, slug, user_hidden,
512 ) in &missing
513 {
514 let is_template = if *is_seeded == 1 { 1_i64 } else { 0_i64 };
515 let parent_template_id = if *is_seeded == 1 {
516 String::new()
517 } else {
518 parent_id.clone()
519 };
520 let affected = tx.execute(
521 "INSERT OR IGNORE INTO db_agents (
522 id, name, icon, description,
523 is_template, parent_template_id,
524 provider, provider_flags, shell, environment,
525 agent_type, agent_bus_id, accounts,
526 auto_start, restart_on_crash, idle_timeout_minutes,
527 slug, branch_label,
528 identity_id, memory_id, working_directory, github_context,
529 instance_name,
530 created_at, updated_at, is_seeded, user_hidden
531 ) VALUES (
532 ?1, ?2, ?3, ?4,
533 ?5, ?6,
534 ?7, ?8, ?9, ?10,
535 ?11, ?12, ?13,
536 ?14, ?15, ?16,
537 ?17, ?18,
538 '', '', ?19, '',
539 '',
540 ?20, ?21, ?22, ?23
541 )",
542 params![
543 id, name, icon, description,
544 is_template, parent_template_id,
545 provider, provider_flags, shell, environment,
546 agent_type, agent_bus_id, accounts,
547 auto_start, restart_on_crash, idle_timeout_minutes,
548 slug, branch_label,
549 working_directory,
550 created_at, updated_at, is_seeded, user_hidden,
551 ],
552 )?;
553 if affected > 0 {
554 warn!(
555 def_id = %id,
556 name = %name,
557 "agents_consolidate: gap-repair inserted missing definition into db_agents"
558 );
559 inserted += 1;
560 }
561 }
562 tx.commit()?;
563
564 if inserted > 0 {
565 info!(
566 count = inserted,
567 "agents_consolidate: gap-repair complete — definitions backfilled"
568 );
569 }
570
571 Ok(inserted)
572}
573
574struct DefRow {
577 id: String,
578 name: String,
579 icon: String,
580 provider: String,
581 description: String,
582 working_directory: String,
583 shell: String,
584 provider_flags: String,
585 auto_start: i64,
586 restart_on_crash: i64,
587 idle_timeout_minutes: i64,
588 created_at: i64,
589 agent_type: String,
590 environment: String,
591 agent_bus_id: String,
592 is_seeded: i64,
593 accounts: String,
594 parent_id: String,
595 branch_label: String,
596 updated_at: i64,
597 slug: String,
598}
599
600struct InstanceRow {
603 id: String,
604 definition_id: String,
605 parent_instance_id: String,
606 instance_name: String,
607 identity_id: String,
608 memory_id: String,
609 working_directory: String,
610 github_context: String,
611 created_at: i64,
612 display_hidden: bool,
613 def_present: bool,
614 def_is_seeded: i64,
615 def_name: String,
616 def_icon: String,
617 def_description: String,
618 def_provider: String,
619 def_provider_flags: String,
620 def_shell: String,
621 def_environment: String,
622 def_agent_type: String,
623 def_agent_bus_id: String,
624 def_accounts: String,
625 def_auto_start: i64,
626 def_restart_on_crash: i64,
627 def_idle_timeout_minutes: i64,
628 def_slug: String,
629 def_branch_label: String,
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::backend::storage::migrations::run_object_schema;
636
637 fn fresh_conn() -> Connection {
638 let conn = Connection::open_in_memory().unwrap();
639 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
640 run_object_schema(&conn).unwrap();
641 conn
642 }
643
644 fn insert_def(
645 conn: &Connection,
646 id: &str,
647 name: &str,
648 is_seeded: i64,
649 parent_id: &str,
650 ) {
651 conn.execute(
652 "INSERT INTO db_agent_definitions
653 (id, slug, name, icon, provider, description, working_directory, shell,
654 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
655 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
656 parent_id, branch_label, updated_at)
657 VALUES (?1, ?2, ?3, '✦', 'claude', 'desc', '', 'bash',
658 '', 0, 0, 0,
659 ?4, 'standalone', '', '', ?5, '',
660 ?6, '', ?4)",
661 params![id, id, name, 1000_i64, is_seeded, parent_id],
662 )
663 .unwrap();
664 }
665
666 fn insert_instance(
667 conn: &Connection,
668 id: &str,
669 definition_id: &str,
670 instance_name: &str,
671 identity_id: &str,
672 memory_id: &str,
673 working_directory: &str,
674 created_at: i64,
675 display_hidden: bool,
676 ) {
677 conn.execute(
678 "INSERT INTO db_agent_instances
679 (id, definition_id, parent_instance_id, block_id, session_id, status,
680 github_context, started_at, ended_at, created_at, identity_id, memory_id,
681 instance_name, working_directory, display_hidden)
682 VALUES (?1, ?2, '', '', '', 'running', '', ?3, 0, ?3, ?4, ?5, ?6, ?7, ?8)",
683 params![
684 id,
685 definition_id,
686 created_at,
687 identity_id,
688 memory_id,
689 instance_name,
690 working_directory,
691 if display_hidden { 1_i64 } else { 0_i64 },
692 ],
693 )
694 .unwrap();
695 }
696
697 fn count_agents(conn: &Connection, where_clause: &str) -> i64 {
698 let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
699 conn.query_row(&sql, [], |row| row.get(0)).unwrap()
700 }
701
702 #[test]
703 fn round_trip_template_user_clone_and_instance_lifecycle() {
704 let mut conn = fresh_conn();
705
706 insert_def(&conn, "tpl-1", "Coder", 1, "");
712 insert_def(&conn, "tpl-2", "Reviewer", 1, "");
713 insert_def(&conn, "def-u1", "Maks-the-Coder", 0, "tpl-1");
714 insert_instance(&conn, "inst-A", "tpl-1", "Maks", "id-1", "mem-1", "/wd/a", 100, false);
715 insert_instance(&conn, "inst-B", "tpl-2", "", "", "", "/wd/b", 200, false);
716 insert_instance(&conn, "inst-C", "def-u1", "Custom", "id-2", "mem-2", "/wd/c", 300, false);
717
718 let stats = run_consolidate_migration(&mut conn, None).unwrap();
719 assert_eq!(stats.templates_inserted, 2);
720 assert_eq!(stats.user_defs_inserted, 1);
721 assert_eq!(stats.instances_as_clone_inserted, 2);
722 assert_eq!(stats.instances_folded_into_def, 1);
723 assert_eq!(stats.instances_skipped_continuation, 0);
724
725 assert_eq!(count_agents(&conn, "is_template = 1"), 2);
727 for tpl in &["tpl-1", "tpl-2"] {
728 let parent: String = conn
729 .query_row(
730 "SELECT parent_template_id FROM db_agents WHERE id = ?1",
731 params![tpl],
732 |r| r.get(0),
733 )
734 .unwrap();
735 assert_eq!(parent, "");
736 }
737
738 assert_eq!(count_agents(&conn, "is_template = 0"), 3);
740
741 let (parent, name, identity): (String, String, String) = conn
743 .query_row(
744 "SELECT parent_template_id, name, identity_id FROM db_agents WHERE id = 'inst-A'",
745 [],
746 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
747 )
748 .unwrap();
749 assert_eq!(parent, "tpl-1");
750 assert_eq!(name, "Maks");
751 assert_eq!(identity, "id-1");
752
753 let name: String = conn
755 .query_row(
756 "SELECT name FROM db_agents WHERE id = 'inst-B'",
757 [],
758 |r| r.get(0),
759 )
760 .unwrap();
761 assert_eq!(name, "Reviewer");
762
763 let (name, identity, memory): (String, String, String) = conn
765 .query_row(
766 "SELECT name, identity_id, memory_id FROM db_agents WHERE id = 'def-u1'",
767 [],
768 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
769 )
770 .unwrap();
771 assert_eq!(name, "Custom"); assert_eq!(identity, "id-2");
773 assert_eq!(memory, "mem-2");
774 }
775
776 #[test]
777 fn multiple_instances_on_one_user_clone_keeps_most_recent_bindings() {
778 let mut conn = fresh_conn();
779 insert_def(&conn, "tpl-1", "Coder", 1, "");
780 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
781 insert_instance(&conn, "inst-old", "def-u1", "Old", "id-OLD", "mem-OLD", "/wd/old", 100, false);
783 insert_instance(&conn, "inst-new", "def-u1", "New", "id-RECENT", "mem-RECENT", "/wd/new", 999, false);
784
785 let stats = run_consolidate_migration(&mut conn, None).unwrap();
786 assert_eq!(stats.instances_collision_warned, 1);
787
788 let identity: String = conn
789 .query_row(
790 "SELECT identity_id FROM db_agents WHERE id = 'def-u1'",
791 [],
792 |r| r.get(0),
793 )
794 .unwrap();
795 assert_eq!(identity, "id-RECENT", "most-recent instance's bindings must win");
796 }
797
798 #[test]
799 fn fold_into_user_clone_stamps_updated_at_from_instance_created_at() {
800 let mut conn = fresh_conn();
809 insert_def(&conn, "tpl-1", "Coder", 1, "");
812 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
813 insert_instance(
815 &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 5000, false,
816 );
817
818 run_consolidate_migration(&mut conn, None).unwrap();
819
820 let updated_at: i64 = conn
821 .query_row(
822 "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
823 [],
824 |r| r.get(0),
825 )
826 .unwrap();
827 assert_eq!(
828 updated_at, 5000,
829 "folded user-clone row's updated_at must reflect the instance's launch time, \
830 not the def's edit time, so ORDER BY updated_at picks the right row"
831 );
832 }
833
834 #[test]
835 fn fold_into_user_clone_keeps_higher_updated_at() {
836 let mut conn = fresh_conn();
840 insert_def(&conn, "tpl-1", "Coder", 1, "");
841 conn.execute(
843 "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'tpl-1'",
844 [],
845 )
846 .unwrap();
847 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
848 conn.execute(
849 "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'def-u1'",
850 [],
851 )
852 .unwrap();
853 insert_instance(
855 &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 3000, false,
856 );
857
858 run_consolidate_migration(&mut conn, None).unwrap();
859
860 let updated_at: i64 = conn
861 .query_row(
862 "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
863 [],
864 |r| r.get(0),
865 )
866 .unwrap();
867 assert_eq!(
868 updated_at, 8000,
869 "later def edit beats older instance launch — MAX(updated_at, inst.created_at)"
870 );
871 }
872
873 #[test]
874 fn marker_short_circuits_second_run() {
875 let mut conn = fresh_conn();
876 insert_def(&conn, "tpl-1", "Coder", 1, "");
877 let tmp = tempfile::tempdir().unwrap();
878
879 let stats1 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
880 assert_eq!(stats1.templates_inserted, 1);
881 assert!(!stats1.already_done);
882 assert!(tmp.path().join(CONSOLIDATE_MARKER).exists());
883
884 insert_def(&conn, "tpl-2", "Reviewer", 1, "");
887 let stats2 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
888 assert!(stats2.already_done);
889 assert_eq!(stats2.templates_inserted, 0);
890 assert_eq!(count_agents(&conn, "is_template = 1"), 1);
893 }
894
895 #[test]
896 fn skips_continuation_rows() {
897 let mut conn = fresh_conn();
898 insert_def(&conn, "tpl-1", "Coder", 1, "");
899 insert_instance(&conn, "inst-A", "tpl-1", "Original", "", "", "/wd/a", 100, false);
900 conn.execute(
902 "INSERT INTO db_agent_instances
903 (id, definition_id, parent_instance_id, block_id, session_id, status,
904 github_context, started_at, ended_at, created_at, identity_id, memory_id,
905 instance_name, working_directory, display_hidden)
906 VALUES ('inst-cont', 'tpl-1', 'inst-A', '', '', 'running', '', 200, 0, 200,
907 '', '', 'Original', '/wd/a', 0)",
908 [],
909 )
910 .unwrap();
911
912 let stats = run_consolidate_migration(&mut conn, None).unwrap();
913 assert_eq!(stats.instances_skipped_continuation, 1);
914 assert_eq!(count_agents(&conn, "1 = 1"), 2);
916 assert_eq!(count_agents(&conn, "id = 'inst-cont'"), 0);
917 }
918
919 #[test]
920 fn empty_database_is_clean_noop() {
921 let mut conn = fresh_conn();
922 let stats = run_consolidate_migration(&mut conn, None).unwrap();
923 assert_eq!(stats, ConsolidateStats::default());
924 assert_eq!(count_agents(&conn, "1 = 1"), 0);
925 }
926
927 #[test]
928 fn preserves_hidden_flag_into_user_hidden() {
929 let mut conn = fresh_conn();
930 insert_def(&conn, "tpl-1", "Coder", 1, "");
931 insert_instance(&conn, "inst-H", "tpl-1", "Hidden", "", "", "/wd/h", 100, true);
932 run_consolidate_migration(&mut conn, None).unwrap();
933 let hidden: i64 = conn
934 .query_row(
935 "SELECT user_hidden FROM db_agents WHERE id = 'inst-H'",
936 [],
937 |r| r.get(0),
938 )
939 .unwrap();
940 assert_eq!(hidden, 1);
941 }
942}